대용량 조회 메모리 관리 (SQL vs Java 필터링)
NOTE
여러 조회 결과를 애플리케이션 레이어에서 조합·필터링하는 구조(오케스트레이션 패턴)에서 발생할 수 있는 메모리 압력을 분석하고, SQL 필터링 vs Java 필터링의 선택 기준·ZGC 튜닝·Cursor 스트리밍으로 방어하는 방법을 정리. 실무에서 추출·일반화.
1. 문제의 구조 — 오케스트레이션 레이어의 메모리 피크
여러 하위 조회(A, B, C)를 순서대로 실행해 결과를 하나의 컨텍스트에 누적한 뒤, 마지막에 합쳐서 응답하는 구조(Fusion/오케스트레이션 패턴)에서는 최종 병합 직전 시점에 모든 하위 결과가 동시에 메모리에 상주한다.
오케스트레이션 실행
├─ 하위 조회 A → 결과 누적 (예: extraData["A"])
├─ 하위 조회 B → 결과 누적
└─ 하위 조회 C → 결과 누적
↑ 병합 직전까지 A+B+C 결과가 동시에 힙에 상주(피크)
└─ 병합 → 최종 응답하위 조회 각각이 소량이면 문제없지만, 대용량 List를 반환하는 하위 조회가 두 개 이상 있으면 피크 메모리가 배수로 증가한다.
2. 케이스별 위험도 판단
| 케이스 | 위험도 | 이유 |
|---|---|---|
| 일반 페이징 조회(LIMIT 있음) | 없음 | SQL LIMIT으로 건수가 통제됨 |
전건 조회(클라이언트 사이드 페이징 등, noLimit=true류) | 낮음~중간 | 비즈니스 조건(날짜 범위 등)으로 건수가 어느 정도 통제되는 화면에 한정해서만 안전 |
| COUNT만 조회 | 없음 | 별도 COUNT 전용 쿼리 사용, 목록 자체를 메모리에 올리지 않음 |
| 대용량 내보내기(엑셀 등) | 없음 | Cursor 스트리밍 등 별도 경로 존재(설계돼 있다면) |
| 오케스트레이션 내부에서 대용량 List 반환 하위 조회를 복수 사용 | 위험 | 각 결과가 병합 전까지 동시 상주 → 피크 배수 증가 |
“noLimit=true 전건 조회”가 특히 위험한 이유: LIMIT을 우회해 사실상 전건을 SQL이 반환하면, Java 필터링(뒷단에서 조건을 걸러내는 로직)이 있어도 필터링 전 원본이 이미 메모리에 전부 올라온 뒤이므로 피크 메모리는 필터링과 무관하게 이미 발생한 상태다.
3. 방어 설계 컨벤션 — “오케스트레이션 레이어에서 대용량 List Micro는 1개 초과 금지”
금지 패턴 — 대용량 List를 반환하는 하위 조회를 오케스트레이션 안에서 복수로 쌓는 것
금지 패턴(N+1) — 대용량 목록을 루프 돌며 하위 조회를 반복 호출하는 것오케스트레이션(Fusion) 레이어의 원래 목적은 소량 데이터의 조합이다. 대용량 데이터를 메모리에 올려 Java로 처리하는 레이어가 아니다 — 데이터가 크면 클수록 인덱스·실행계획·버퍼 캐시를 활용하는 DB 쪽이 압도적으로 효율적이다.
결론(재사용 가능한 원칙):
| 케이스 | 올바른 설계 |
|---|---|
| 단건/소량 조회 후 연관 데이터 조합 | 오케스트레이션 레이어(Fusion) |
| 복잡한 비즈니스 로직 조합(소량) | 오케스트레이션 레이어 |
| 대용량 + 필터링 + 집계 | SQL JOIN/WHERE로 해결 — 수십만 건이 될 수 있는 테이블을 메인으로 전건 적재 후 Java에서 필터링하는 패턴은 금지 |
4. 모니터링 — 경고 로그로 이상 탐지
구조적으로 완전히 막기 어려운 경우(비즈니스 요구로 전건 조회가 필요한 화면 등), 하위 조회 결과 건수가 임계값을 넘으면 경고 로그를 남겨 “오케스트레이션 안에서 대용량 동시 조회가 발생하고 있음”을 조기에 감지한다.
if (result != null && result.get("list") instanceof List<?> list && list.size() > 10000) {
log.warn("[MEMORY-WARN] {} → {}건 적재. 오케스트레이션 내 동시 대용량 조회 확인 필요",
beanName, list.size());
}추가 코드 방어(전면 리팩토링)보다 설계 컨벤션 준수 + 경고 로그가 비용 대비 효과가 높은 경우가 많다.
5. JVM 튜닝 — 대용량 객체가 오갈 수 있는 서비스의 GC 선택
이런 구조를 유지해야 한다면 GC 선택도 방어선의 일부다.
-XX:+UseZGC -XX:+ZGenerational
-Xms4g -Xmx4g
-XX:SoftMaxHeapSize=3gG1GC 대비 ZGC는 “거대 객체(Humongous)“라는 별도 개념이 없고, STW(Stop-The-World) 정지 시간이 수 밀리초 이하로 유지된다 — 큰 List 객체가 순간적으로 힙에 올라왔다 사라지는 패턴에서 지연 스파이크를 줄이는 데 유리하다.
6. Cursor 스트리밍 — 진짜 대용량 처리 경로는 별도로 분리
대용량이 예정된 경로(엑셀 다운로드 등)는 애초에 오케스트레이션/List 방식이 아니라 Cursor 스트리밍 전용 경로로 분리해야 한다. 행 단위로 흘려보내며 처리하므로 힙에는 항상 소수의 행만 상주한다(자세한 설계는 (HTTP) 대용량 처리 비동기 Job 큐 설계 패턴 - 핵심 개념 및 특징 정리 참고).
7. 요약 원칙
- 페이징/COUNT는 SQL LIMIT·전용 COUNT 쿼리로 통제한다(Java 레벨 건수 통제에 의존하지 않는다).
- 오케스트레이션 레이어에서 대용량 List 반환 하위 조회는 1개를 넘기지 않는다.
- 대용량+필터링+집계가 필요하면 SQL(JOIN/WHERE/GROUP BY)로 내려보낸다 — Java 필터링은 이미 메모리에 전건이 올라온 뒤이므로 필터링해도 피크는 못 줄인다.
- 진짜 대용량 처리(내보내기 등)는 Cursor 스트리밍 전용 경로로 분리한다.
- 완전히 막기 어려운 전건 조회 화면은 건수 임계값 경고 로그로 조기 탐지한다.
- 대용량 객체가 오가는 서비스는 ZGC 등 저지연 GC를 검토한다.